SnapStartでリストアされたLambda実行環境のMACアドレスが再生成されるか確認したら不思議な結果になった話
CX事業本部@大阪の岩田です。
LambdaのSnapStart機能を使う際にはRuntime hookと呼ばれるフックポイントを利用することで、スナップショットの取得前やリストア時に任意の処理を追加で実行できます。このフックポイントを利用し、スナップショット取得前のLambda実行環境のMACアドレスとスナップショットからリストアした後のLambda実行環境のMACアドレスが別のものになっていることを確認しようとしたところ...
なんとも不思議な結果になりました。
Runtime hookの実装方法
まずRuntime hookの実装方法について簡単に解説しておきます。
まずRuntime hookの実装にはorg.cracパッケージを利用します。Lambdaの依存関係にこのパッケージを含めるようにして下さい。mavenで管理している場合は以下のようになります。
...略 <dependencies> <dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-lambda-java-core</artifactId> <version>1.2.1</version> </dependency> <dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-lambda-java-events</artifactId> <version>3.11.0</version> </dependency> <dependency> <groupId>io.github.crac</groupId> <artifactId>org-crac</artifactId> <version>0.1.3</version> </dependency> ...略
Javaのコードの方ではorg.crac.Resource
インターフェースを実装したクラスでbeforeCheckpoint
とafterRestore
をオーバーライドしつつ、ランタイムが管理するContextにこのクラスを登録するというのがざっくりの流れになります。公式ドキュメントに記載されている以下のコードを見ていただくとイメージがつきやすいと思います。
... import org.crac.Resource; import org.crac.Core; ... public class CRaCDemo implements RequestStreamHandler, Resource { public CRaCDemo() { Core.getGlobalContext().register(this); } public String handleRequest(String name, Context context) throws IOException { System.out.println("Handler execution"); return "Hello " + name; } @Override public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception { System.out.println("Before checkpoint"); } @Override public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception { System.out.println("After restore");
今回はこのRuntime hookのbeforeCheckpoint
を使い、スナップショット取得前の時点におけるLambda実行環境のMACアドレスを/tmp/ディレクトリ配下のテキストファイルに書き出しておき、スナップショットリストア後に改めてLambda実行環境のMACアドレスを取得、先程作成したテキストファイルの情報と一致するかを確かめてみます。
Lambdaの実装
ではさっそくLambdaを実装していきましょう。コード全体は以下の様になりました。
※ちょっとした検証目的なので例外制御などが適当なのはご了承下さい。
package helloworld; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.net.NetworkInterface; import java.net.SocketException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.*; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import org.crac.Core; import org.crac.Resource; public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>, Resource { static final String MAC_ADDR_FILE_PATH = "/tmp/macaddr.txt"; private static ArrayList<String> getNicInfos() throws SocketException { ArrayList<String> nicInfos = new ArrayList<>(); for (NetworkInterface networkInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) { byte[] macAddr = networkInterface.getHardwareAddress(); nicInfos.add(networkInterface.getName() + ":" + macAddrToStr(macAddr)); } return nicInfos; } private static String macAddrToStr(byte[] mac) { if (mac == null) { return ""; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < mac.length; i++) { sb.append(String.format("%02X%s", mac[i], (i < mac.length - 1) ? "-" : "")); } return sb.toString(); } public App() { Core.getGlobalContext().register(this); } @Override public void beforeCheckpoint(org.crac.Context<? extends Resource> context) { System.out.println("チェックポイント前のフックポイント"); try{ List<String> nicInfos = getNicInfos(); File file = new File(MAC_ADDR_FILE_PATH); try(BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { for(String nic : nicInfos){ bw.write(nic); bw.newLine(); } } System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); } catch (Exception ex){ System.out.println(ex); } } @Override public void afterRestore(org.crac.Context<? extends Resource> context) { System.out.println("リストア後のフックポイント"); System.out.println("ファイルから読み込んだNicの情報"); try { System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); List<String> nicInfos = getNicInfos(); System.out.println("リストア後に再取得したNicの情報"); nicInfos.forEach(s-> System.out.println(s)); } catch (Exception ex){ System.out.println(ex); } } public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); System.out.println("handler処理"); System.out.println("ファイルから読み込んだNicの情報"); try { System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); List<String> nicInfos = getNicInfos(); System.out.println("Handler内で再取得したNicの情報"); nicInfos.forEach(s-> System.out.println(s)); Thread.sleep(2000); return response .withStatusCode(200) .withBody("{}"); } catch (Exception e) { System.out.println(e); return response .withBody("{}") .withStatusCode(500); } } }
ポイントをいくつか解説します。まずスナップショット取得前に実行されるbeforeCheckpoint
の処理です。
ここで/tmp/macaddr.txt
というファイルにLambda実行環境のMACアドレスを書き出した後、ファイルに書き出した内容をそのままログに出力しています。
@Override public void beforeCheckpoint(org.crac.Context<? extends Resource> context) { System.out.println("チェックポイント前のフックポイント"); try{ List<String> nicInfos = getNicInfos(); File file = new File(MAC_ADDR_FILE_PATH); try(BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { for(String nic : nicInfos){ bw.write(nic); bw.newLine(); } } System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); } catch (Exception ex){ System.out.println(ex); } }
続いてafterRestore
です。スナップショット取得前に書き出された/tmp/macaddr.txt
というファイルの内容をログに出力した後、改めてLambda実行環境のMACアドレスを取得してログに出力します。スナップショットから起動したLambda実行環境のMACアドレスはスナップショット取得前から変わっているだろうと予想しての実装です。
@Override public void afterRestore(org.crac.Context<? extends Resource> context) { System.out.println("リストア後のフックポイント"); System.out.println("ファイルから読み込んだNicの情報"); try { System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); List<String> nicInfos = getNicInfos(); System.out.println("リストア後に再取得したNicの情報"); nicInfos.forEach(s-> System.out.println(s)); } catch (Exception ex){ System.out.println(ex); } }
一応handlerにもafterRestore
と同等のMACアドレス比較処理を実装しておきます。
public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); System.out.println("handler処理"); System.out.println("ファイルから読み込んだNicの情報"); try { System.out.println(Files.readString(Paths.get(MAC_ADDR_FILE_PATH))); List<String> nicInfos = getNicInfos(); System.out.println("Handler内で再取得したNicの情報"); nicInfos.forEach(s-> System.out.println(s)); Thread.sleep(2000); return response .withStatusCode(200) .withBody("{}"); } catch (Exception e) { System.out.println(e); return response .withBody("{}") .withStatusCode(500); } }
やってみる
コードの準備ができたので実際に試していきましょう。
Lambdaのデプロイとバージョン発行
まずLambdaのデプロイとバージョン発行を行います。SnapStartが有効なLambdaの新しいバージョンを発行すると、Lambdaの基盤側でスナップショットの作成処理が実行されます。CW Logsを確認すると以下のようなログが出力されていました。
INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca INIT_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca チェックポイント前のフックポイント チェックポイント前のフックポイント チェックポイント前のフックポイント telemetry1_sb:D2-B7-A5-18-A6-08 vinternal_1:1A-A7-38-DD-C9-B3 lo: telemetry1_sb:56-CF-FB-ED-4E-64 vinternal_1:62-91-75-B8-FF-28 lo: チェックポイント前のフックポイント チェックポイント前のフックポイント チェックポイント前のフックポイント INIT_REPORT Init Duration: 841.67 ms ...略
お?LambdaのInit処理実行が1回だけ実行されてスナップショットを生成するものとばかり思っていましたが、どうも複数回(ログを集計して調べたところ10回)実行されるようです。AZごとに実行するにしても10回は多すぎますし、裏側はどうなってるんでしょうね?
バージョン発行完了後にログを集計した結果は以下のようになりました。※ループバックアドレスの情報は不要なので除外しています。
インターフェース名 | MACアドレス |
---|---|
telemetry1_sb | B6-CE-23-7D-31-46 |
telemetry1_sb | B6-5A-2E-72-9F-B6 |
telemetry1_sb | 8E-E8-3E-15-78-10 |
telemetry1_sb | A2-1E-78-CF-DF-58 |
telemetry1_sb | CE-7A-FE-62-70-B0 |
telemetry1_sb | 56-A0-C6-86-74-AB |
telemetry1_sb | AA-2D-46-4F-4D-3E |
telemetry1_sb | EA-B2-E6-FB-88-BB |
telemetry1_sb | 56-CF-FB-ED-4E-64 |
telemetry1_sb | D2-B7-A5-18-A6-08 |
vinternal_1 | EE-13-44-8F-5E-18 |
vinternal_1 | 0A-23-C0-D1-F4-82 |
vinternal_1 | 42-F4-BF-08-F0-98 |
vinternal_1 | 3E-F3-74-E1-74-37 |
vinternal_1 | 86-01-3B-B9-EE-73 |
vinternal_1 | C6-05-BF-68-00-D5 |
vinternal_1 | 66-CD-98-60-DF-46 |
vinternal_1 | 42-E4-83-B9-FA-B9 |
vinternal_1 | 62-91-75-B8-FF-28 |
vinternal_1 | 1A-A7-38-DD-C9-B3 |
Init処理が10回も起動するのは予想外でしたが、MACアドレスは10(Init実行回数) × 2(ループバックアドレスアドレスを除くインターフェース数)で20種類のMACアドレスの情報が出力されました。これらのMACアドレスはこれから起動するLambda実行環境のいわば「祖先」が使用していたMACアドレスということになります。この祖先(スナップショット)から派生して起動したLambda実行環境のMACアドレスはどうなるのか?というのがこの検証の趣旨です。
スナップショットからLambdaを実行してみる
準備ができたので、先程発行したLambdaのバージョンをテスト実行してログを確認してみましょう。ログは以下のようになりました。
RESTORE_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca リストア後のフックポイント ファイルから読み込んだNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: リストア後に再取得したNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: RESTORE_REPORT Restore Duration: 326.41 ms START RequestId: 19d678c9-04da-4443-a5a6-347dc839c85f Version: 12 handler処理 ファイルから読み込んだNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: Handler内で再取得したNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: END RequestId: 19d678c9-04da-4443-a5a6-347dc839c85f REPORT RequestId: 19d678c9-04da-4443-a5a6-347dc839c85f Duration: 2050.47 ms Billed Duration: 2282 ms Memory Size: 1024 MB Max Memory Used: 82 MB Restore Duration: 326.41 ms
結果をまとめると以下のようになります。
リストア直後にファイルから読み込んだMACアドレス | リストア直後にLambda実行環境から取得したMACアドレス | handler内でファイルから読み込んだMACアドレス | handler内でLambda実行環境から取得したMACアドレス | |
---|---|---|---|---|
telemetry1_sb | 52-CF-5C-CF-D5-46 | 52-CF-5C-CF-D5-46 | 52-CF-5C-CF-D5-46 | 52-CF-5C-CF-D5-46 |
vinternal_1 | 4E-E3-B5-37-A8-D6 | 4E-E3-B5-37-A8-D6 | 4E-E3-B5-37-A8-D6 | 4E-E3-B5-37-A8-D6 |
telemetry1_sb、vinternal_1ともにファイルから読み込んだMACアドレス...つまり祖先となるLambda実行環境のMACアドレスと、スナップショットから復元されたLambda実行環境から取得したMACアドレスが同一になりました。
これだけならスナップショットから復元されたLambda実行環境はスナップショット取得時点のLambda実行環境と同じMACアドレスを使い続けると結論づけてしまうのですが、よく見るとこの52-CF-5C-CF-D5-46
というMACアドレスと4E-E3-B5-37-A8-D6
というMACアドレスは両方ともLambdaのバージョン発行時に出力されたログの中には存在しないMACアドレスなのです。
ん???ということはスナップショット取得前に書き出していたファイル/tmp/macaddr.txt
の中身がスナップショット復元時に書き換えられたということでしょうか??とでも考えないとこの検証結果の説明が付かないですよね?とはいったものの、仮にLambda実行環境の方で/tmp/macaddr.txt
の中身を書き換えているとした場合、どうやって書き換え対象のファイルを追跡しているんだ?という疑問が残ります。Lambda実行環境のシステムコールを監視すれば、そういったことも可能なのかな?と思ったりしましたが、/tmp/macaddr.txt
に書き込んだ内容がMACアドレスで、どういう値に書き換えるべきかという判定はさすがに不可能なように思います。いったいこれはどういうことでしょう??本当に謎です。
ちなみにこの検証用のLambdaですが、最初はテキストファイルを使わずstatic変数でMACアドレスの情報を保存/読み込みしていました。その実装の時もスナップショット取得前にstatic変数に保存したMACアドレスの情報とスナップショットからリストア後にLambda実行環境から取得したMACアドレスは一致しており、かつこのMACアドレスはInit処理実行時のログには出力されていないMACアドレスでした。
うーん。謎ですね。
さらに時間を開けてスナップショットからLambdaを実行してみる
先程起動したLambda実行環境が破棄されるまで十分な時間を空けてから再度Lambdaを実行してみました。
RESTORE_START Runtime Version: java:11.v15 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:0a25e3e7a1cc9ce404bc435eeb2ad358d8fa64338e618d0c224fe509403583ca リストア後のフックポイント ファイルから読み込んだNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: リストア後に再取得したNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: RESTORE_REPORT Restore Duration: 132.29 ms START RequestId: 9f9c2725-e5c1-47b0-b11f-c7b8e28b231d Version: 12 handler処理 ファイルから読み込んだNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: Handler内で再取得したNicの情報 telemetry1_sb:52-CF-5C-CF-D5-46 vinternal_1:4E-E3-B5-37-A8-D6 lo: END RequestId: 9f9c2725-e5c1-47b0-b11f-c7b8e28b231d REPORT RequestId: 9f9c2725-e5c1-47b0-b11f-c7b8e28b231d Duration: 2025.87 ms Billed Duration: 2074 ms Memory Size: 1024 MB Max Memory Used: 82 MB Restore Duration: 132.29 ms
フックポイントのログが出力されており、REOPRTにRestore Duration
という表示があることからもコールドスタートが発生し、Lambda実行環境がスナップショットから復元されたことが分かります。ログに出力されたMACアドレスは先程のLambda初回実行時と同一のMACアドレスでした。
この実行結果から考えると、スナップショットリストア時にMACアドレスの再生成のような処理は実行されず、あくまでスナップショット取得時点のMACアドレスを使い回すということでしょうか?
ただし、依然としてInit処理のログに出力されたMACアドレスが利用されていないことの説明が付きません。うーんん。謎だ。。。
Lambdaを並列実行して複数の実行環境をコールドスタートさせてみる
ここまでの検証はLambdaを1回づつテスト実行してログを確認するという手順を踏んでいました。今度はLambdaを並列実行して複数のLambda実行環境を生成、これらのLambda実行環境が利用するMACアドレスがどうなるかを確認してみましょう。LambdaをAPI GWのバックに紐付けた後、以下のコマンドを実行しAPI GWのエンドポイントに30並列でアクセスします。
ab -c 30 -n 30 https://APIGWのエンドポイント
これで大体コールドスタートが30回発生...つまりスナップショットからリストアされたLambda実行環境が30個ほど生成されるのが期待値です。
CW Logs InsightsからLambdaのログを確認してみましょう
fields @timestamp, @message | filter (@message like /RESTORE_REPORT/ ) | stats count(*)
スナップショットからのリストア(コールドスタート)は29回発生していました。ほぼ期待通りの結果です。
count(*) |
---|
29 |
続いてログに出力されたMACアドレスの情報を集計してみます。
fields @timestamp, @message | filter (@message like /telemetry1_sb/ or @message like /vinternal_1/) | parse @message "*:*" as nicName, macAddr | stats count(*) by nicName, macAddr | sort nicName, macAddr
結果は以下のようになりました。
インターフェース名 | MACアドレス | 件数 |
---|---|---|
telemetry1_sb | 4E-26-89-09-4F-E9 | 22 |
telemetry1_sb | 52-CF-5C-CF-D5-46 | 20 |
telemetry1_sb | 56-EF-BD-EC-9D-95 | 24 |
telemetry1_sb | D6-14-67-61-22-35 | 28 |
telemetry1_sb | EE-B3-42-FC-F7-80 | 24 |
vinternal_1 | 4E-E3-B5-37-A8-D6 | 20 |
vinternal_1 | 12-CE-AA-1A-62-F0 | 22 |
vinternal_1 | 26-88-4A-8D-D5-12 | 28 |
vinternal_1 | 76-46-96-74-99-80 | 24 |
vinternal_1 | DE-C6-6B-01-1F-F1 | 24 |
合計 | 236 |
Lambdaのロジック的にコールドスタート時はMACアドレスの情報が8回、ウォームスタート時には4回ログに出力されるので先程のコールドスタート29回という結果から計算すると 29 * 8 + 4 = 236で集計結果の合計236件と一致していますね。MACアドレスは10種類出力されているので、これをLambda実行環境のNIC数2で割ると5となります。今回の検証結果では5パターンのMACアドレスを利用していると考えられますが、作成されたLambda実行環境の数はコールドスタートの回数である29となり、複数のLambda実行環境で同一のMACアドレスを利用していることが分かります。
さらにこの出力されたMACアドレスですが、いずれのMACアドレスもInit処理実行時のログには存在しないMACアドレスとなります。一体なぜ...
Init処理実行時に利用していたMACアドレスは一体どこに行ったんだ...
まとめ
この現象についてなにか思い当たる点がある方いれば是非教えて下さい。